-
Notifications
You must be signed in to change notification settings - Fork 87
Replace old value after null merges in nested properties when batching merges/updates
#620
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Replace old value after null merges in nested properties when batching merges/updates
#620
Conversation
|
Strangely the Maybe the |
|
My tests were "wrong", now it will output the errors in the two scenarios of both Onyx.update() and Onyx.merge() |
null merges in nested properties when batching merges/updatesnull merges in nested properties when batching merges/updates
lakchote
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Left some comments
lib/utils.ts
Outdated
| // To achieve this, we first mark these nested objects with an internal flag. With the desired objects | ||
| // marked, when calling this method again with "shouldReplaceMarkedObjects" set to true we can proceed | ||
| // to effectively replace them in the next condition. | ||
| if (isBatchingMergeChanges && targetValue === null) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we do something like this?
| if (isBatchingMergeChanges && targetValue === null) { | |
| if (isBatchingMergeChanges && !targetValue) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should explicitly check for null. undefined values are not supposed to delete a key. Effectively, they should never be in store anyway, but i think making this explicit makes more sense
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We did that previously, but actually for this logic, we might want to rethink that.
If we merge a nested object with an existing key, we would expect undefined values to be set, rather than discarded.
So in this case i would still explicitly check for if (targetValue == null) (which includes undefined values)
chrispader
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM! I added a few comments around the SQLite part and tests, but this has a GO from me!
lib/utils.ts
Outdated
| // To achieve this, we first mark these nested objects with an internal flag. With the desired objects | ||
| // marked, when calling this method again with "shouldReplaceMarkedObjects" set to true we can proceed | ||
| // to effectively replace them in the next condition. | ||
| if (isBatchingMergeChanges && targetValue === null) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should explicitly check for null. undefined values are not supposed to delete a key. Effectively, they should never be in store anyway, but i think making this explicit makes more sense
lib/utils.ts
Outdated
| // To achieve this, we first mark these nested objects with an internal flag. With the desired objects | ||
| // marked, when calling this method again with "shouldReplaceMarkedObjects" set to true we can proceed | ||
| // to effectively replace them in the next condition. | ||
| if (isBatchingMergeChanges && targetValue === null) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We did that previously, but actually for this logic, we might want to rethink that.
If we merge a nested object with an existing key, we would expect undefined values to be set, rather than discarded.
So in this case i would still explicitly check for if (targetValue == null) (which includes undefined values)
| return this.multiGet(nonNullishPairsKeys).then((storagePairs) => { | ||
| // multiGet() is not guaranteed to return the data in the same order we asked with "nonNullishPairsKeys", | ||
| // so we use a map to associate keys to their existing values correctly. | ||
| const existingMap = new Map<OnyxKey, OnyxValue<OnyxKey>>(); | ||
| // eslint-disable-next-line @typescript-eslint/prefer-for-of | ||
| for (let i = 0; i < storagePairs.length; i++) { | ||
| existingMap.set(storagePairs[i][0], storagePairs[i][1]); | ||
| } | ||
|
|
||
| const newPairs: KeyValuePairList = []; | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/prefer-for-of | ||
| for (let i = 0; i < nonNullishPairs.length; i++) { | ||
| const key = nonNullishPairs[i][0]; | ||
| const newValue = nonNullishPairs[i][1]; | ||
|
|
||
| const existingValue = existingMap.get(key) ?? {}; | ||
|
|
||
| const mergedValue = utils.fastMerge(existingValue, newValue, true, false, true); | ||
|
|
||
| newPairs.push([key, mergedValue]); | ||
| } | ||
|
|
||
| return this.multiSet(newPairs); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm really not sure about the performance implications of this change on native.
I know it is hard to have SQLite handle merges with through ON CONFLICT DO UPDATE and JSON_PATCH, but this was supposed to add significant performance gains, since we can basically offload all of the merging (at least with the existing values in store) to the low-level C++ implementation of SQLite. Ideally we would want to let SQLite handle as much of merging/batching as possible.
I see though, that we already had a lot of exceptions to this before and that we still always needed to "pre-merge" in order to broadcast the update, so i think it should be fine for now. In a bigger re-design we could tackle and improve all of this, to make sure that each platform is facilitated to it's fullest.
Still, do we have any benchmarks around how this affects performance of simple Onyx.merge operations?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes I think it would be a good idea to measure performance in the app before and after this change to make sure it doesn't cause a large performance hit.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 but I am less convinced that this is "fine for now". It feels like we are undoing work that we had a good reason to do at some point. I trust that we are moving in a good direction, but would rather let some benchmarks do the talking.
neil-marcellini
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with the changes others suggested. I would like to see the tests cleaned up a little and agree with Chris that we should measure the performance impact of this.
I'm also going to request a review from @marcaaron now that he is back and because he has a good perspective about how Onyx should work.
| return this.multiGet(nonNullishPairsKeys).then((storagePairs) => { | ||
| // multiGet() is not guaranteed to return the data in the same order we asked with "nonNullishPairsKeys", | ||
| // so we use a map to associate keys to their existing values correctly. | ||
| const existingMap = new Map<OnyxKey, OnyxValue<OnyxKey>>(); | ||
| // eslint-disable-next-line @typescript-eslint/prefer-for-of | ||
| for (let i = 0; i < storagePairs.length; i++) { | ||
| existingMap.set(storagePairs[i][0], storagePairs[i][1]); | ||
| } | ||
|
|
||
| const newPairs: KeyValuePairList = []; | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/prefer-for-of | ||
| for (let i = 0; i < nonNullishPairs.length; i++) { | ||
| const key = nonNullishPairs[i][0]; | ||
| const newValue = nonNullishPairs[i][1]; | ||
|
|
||
| const existingValue = existingMap.get(key) ?? {}; | ||
|
|
||
| const mergedValue = utils.fastMerge(existingValue, newValue, true, false, true); | ||
|
|
||
| newPairs.push([key, mergedValue]); | ||
| } | ||
|
|
||
| return this.multiSet(newPairs); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes I think it would be a good idea to measure performance in the app before and after this change to make sure it doesn't cause a large performance hit.
Can you please update the description to mention which customizations are not compatible with JSON_PATCH? I will review today, thanks! |
|
Is it possible to reword some of this for clarity?
Specifically, "handle the replacement of old values after null merges" didn't quite make sense to me.
A part of me also finds the description a bit confusing. Maybe you can also explain what a "marked object" is in the description? Thanks! |
marcaaron
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is generally looking good. Thanks for the work on it @fabioh8010 @chrispader. My main feedback (that @neil-marcellini already called out) would be to make sure that JSON_PATCH in the native provider is something we can confidently get rid of. If so, then 👍.
| return this.multiGet(nonNullishPairsKeys).then((storagePairs) => { | ||
| // multiGet() is not guaranteed to return the data in the same order we asked with "nonNullishPairsKeys", | ||
| // so we use a map to associate keys to their existing values correctly. | ||
| const existingMap = new Map<OnyxKey, OnyxValue<OnyxKey>>(); | ||
| // eslint-disable-next-line @typescript-eslint/prefer-for-of | ||
| for (let i = 0; i < storagePairs.length; i++) { | ||
| existingMap.set(storagePairs[i][0], storagePairs[i][1]); | ||
| } | ||
|
|
||
| const newPairs: KeyValuePairList = []; | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/prefer-for-of | ||
| for (let i = 0; i < nonNullishPairs.length; i++) { | ||
| const key = nonNullishPairs[i][0]; | ||
| const newValue = nonNullishPairs[i][1]; | ||
|
|
||
| const existingValue = existingMap.get(key) ?? {}; | ||
|
|
||
| const mergedValue = utils.fastMerge(existingValue, newValue, true, false, true); | ||
|
|
||
| newPairs.push([key, mergedValue]); | ||
| } | ||
|
|
||
| return this.multiSet(newPairs); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 but I am less convinced that this is "fine for now". It feels like we are undoing work that we had a good reason to do at some point. I trust that we are moving in a good direction, but would rather let some benchmarks do the talking.
This comment was marked as outdated.
This comment was marked as outdated.
ikevin127
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧪 E/App Manual Review Report
Note
- performed the testing by building this PR's
react-native-onyxand replacing the builtdistin E/App - tests will be performed on all devices: Android & iOS: HybridApp (Native), Android & iOS: mWeb, Web and Desktop
SignUp / Login Flow
- ✅ on clear cache, sign up with a new account (public & private domain)
- ✅ while logged-in, logout and sign up with a new account (public & private domain)
- ✅ on clear cache, login with an existing account (public & private domain)
- ✅ while logged-in, login with another existing account (public & private domain)
- ✅ while anonymous in a public room, login / sign-up
- ✅ switch between ND / OD and back
- once logged-in / signed-up -> navigate through the app to verify everything looks as expected
Troubleshoot: Clear Cache and Restart Functionality
- ✅ on a new account, start / create / add some messages in a: selfDM, 1-1 DM, workspace chat, group & room chats
- verify that upon
Clear cache and restarttrigger, the LHN report previews look as expected for each of the 'start / create / add messages' report types
- verify that upon
Report Actions (aka Messages / Comments: these directly use Onyx's merge/update)
- ✅ post / update / delete a message on a report / report thread (including offline -> online transition)
- ✅ add emojis on messages / nested thread messages (including offline -> online transition)
- ✅ search router functionality (
CMD + K) outside of Reports page - ✅ reports page functionality (filtering, sorting, manually typed queries)
Expense Tracking and Submission
- ✅ submit manual / scan / distance expense (including offline -> online transition)
- ✅ replace scan expense receipt after it was posted (including offline -> online transition)
Pending Actions (offline -> online transitions)
- ✅ while offline, post / update / delete a message on a report / report thread
- ✅ while offline, submit manual / scan / distance expense
- then delete them, then return online
User Interactions
- ✅ mark reports read / unread (including offline -> online transition)
- ✅ pin / unpin reports (including offline -> online transition)
- ✅ expenses: approve / submit / mark as paid (including offline -> online transition)
- ✅ copy onyx / export onyx data functionality
- ✅ offline mode functionality
Summary
🎉 No issues found, E/App behaving as expected with the update in all scenarios mentioned above.
neil-marcellini
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here's a very small start on reviewing this. I will tackle it tomorrow.
lakchote
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Left one NAB comment, otherwise looks good
|
|
||
| // For native platforms we use `mergeItem` that will take advantage of JSON_PATCH and JSON_REPLACE SQL operations to | ||
| // merge the object in a performant way. | ||
| return Storage.mergeItem(key, batchedChanges as OnyxValue<TKey>, replaceNullPatches).then(() => ({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NAB: should we catch eventual errors and log them?
Yep I believe so, will check this 👍
|
Reviewing again 👀 |
|
@neil-marcellini @mountiny Extracted the |
neil-marcellini
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have reviewed the whole thing before you extracted the removeNestNulls thing, so I can go and quickly review that separate PR. I will also copy over any relevant comments.
I'm requesting changes because I have a few important questions and some small tweaks I would like to see. Great job on this and especially persisting with it over time.
Also as a heads up, I'm going to be out of office from July 4-16. If we don't get this merged by then for any reason, please go ahead without my review.
| if (replaceQueryArguments.length > 0) { | ||
| commands.push({query: replaceQuery, params: replaceQueryArguments}); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NAB: Future performance optimization?: If we need to do several replacements on the same key, maybe we could pass a series of key, value pairs to the JSON_REPLACE SQLite function. Given that it supports that, I wonder if it's faster than this approach.
Not something we need to change since the performance of this PR seems to be good enough. Would be interesting to look into though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I really like the way you wrote the tests in this file. I find them to be easy to read and track how the values are changing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks!
|
@neil-marcellini Addressed/answered all comments! |
neil-marcellini
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for the updates. It all looks good to me now. I think it's good to go. We can follow up on some NAB items later.
|
I'm going to merge this. I think it's been going on long enough and I feel like it's pretty solid at this point. |
Marc is OOO

Details
#615 (comment)
This PR:
Onyx.merge()/Onyx.update()batching logic to replace the current value of a nested property after anullchange in it, so the subsequent updates of that batch in this nested property can fully reset its data.OnyxMergefile that will handle the merging of data in different ways depending on the platformmergeItemthat will take advantage ofJSON_PATCHandJSON_REPLACESQL operations to merge the object in a performant way.JSON_PATCHoperations were already being used before, and now we also useJSON_REPLACEoperations to fully replace the marked nested objects when merging data.setItemsince the object was already merged with its changes before.fastMerge/mergeObjectin order to mark the nested objects we need to fully replace, more details are commented in the functions.OnyxUtils.removeNullValuessince it won't be necessary anymore after the changes in this PR.OnyxUtils.mergeChanges/OnyxUtils.mergeAndMarkChangesto handle the batching of merge changes or just merge data into a value.Related Issues
#615
Automated Tests
Unit tests were added to cover these changes.
Manual Tests
Same as Expensify/App#55199.
Author Checklist
### Related Issuessection aboveTestssectiontoggleReportand notonIconClick)myBool && <MyComponent />.STYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)/** comment above it */thisproperly so there are no scoping issues (i.e. foronClick={this.submit}the methodthis.submitshould be bound tothisin the constructor)thisare necessary to be bound (i.e. avoidthis.submit = this.submit.bind(this);ifthis.submitis never passed to a component event handler likeonClick)Avataris modified, I verified thatAvataris working as expected in all cases)mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Screen.Recording.2025-03-25.at.22.13.11-compressed.mov
Android: mWeb Chrome
I'm having problems with my emulators when opening the Chrome app (they crash instantly), so I couldn't record videos for this platform.
iOS: Native
Screen.Recording.2025-03-25.at.22.29.49-compressed.mov
iOS: mWeb Safari
Screen.Recording.2025-03-25.at.22.34.02-compressed.mov
MacOS: Chrome / Safari
Screen.Recording.2025-03-25.at.22.36.15-compressed.mov
Screen.Recording.2025-03-25.at.22.37.28-compressed.mov
MacOS: Desktop
Screen.Recording.2025-03-25.at.22.43.39-compressed.mov